<?php

/*
 * Copyright (C) 2006-2009 Pham Cong Dinh
 *
 * This file is part of Pone.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 3 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */

/**
 * Utility class that helps rewrite named parameter queries into positional parameter ones
 *
 * @category   spica
 * @package    core
 * @subpackage datasource\db
 * @author     Pham Cong Dinh <pcdinh at phpvietnam dot net>
 * @since      Version 0.1
 * @since      October 26, 2008
 * @copyright  Pham Cong Dinh (http://www.phpvietnam.net)
 * @license    http://www.gnu.org/licenses/lgpl-3.0.txt
 * @version    $Id: NamedParameterParser.php 1477 2009-12-11 15:29:41Z pcdinh $
 */
class SpicaNamedParameterParser
{
    /**
     * Special operator: :: (Postgres) and := (MySQL)
     *
     * @var array
     */
    private static $_skippedSymbols = array('::', ':=');

    /**
     * Set of characters that qualify as parameter separators,
     * indicating that a parameter name in a SQL String has ended.
     */
    private static $_separators = array('"', '\'', ':', '&', ',', ';', '(', ')', '|', '=', '+', '-', '*', '%', '/', '\\', '<', '>', '^');

    /**
     * Set of characters that qualify as comment or quotes starting characters
     */
    private static $_startSkippedBlock = array("'", '"', '--', '/*');

    /**
     * Set of characters that at are the corresponding comment or quotes ending characters.
     */
    private static $_endSkippedBlock = array("'", '"', array("\n", "\r\n", "\r"), '*/');

    /**
     * Directory path to keep rewritten SQL.
     *
     * @var string
     */
    private static $_cachePath;

    /**
     * Cache mode.
     *
     * @var bool
     */
    private static $_cacheMode = true;

    /**
     * Prevents construction of a new instance of the {@link SpicaNamedParameterParser} class
     */
    private function  __construct()
    {

    }

    /**
     * Parses the SQL query and locate any positional or named parameters.
     * Named parameters are substituted for a positional parameter (a question mark).
     *
     * @throws SpicaDatabaseException when a duplicate named parameter is found
     * @param  string $sql the SQL statement
     * @return SpicaNamedParameterQuery  The query object that contains information about the named parameter SQL after being processed
     */
    public static function rewriteSqlStatement($sql)
    {
        if (true === self::$_cacheMode && true === self::isCached($sql))
        {
            return self::getCachedObject($sql);
        }

        $namedParameters          = array();
        $namedParameterQuery      = new SpicaNamedParameterQuery($sql);
        $namedParameterCount      = 0;
        $positionalParameterCount = 0;
        $totalParameterCount      = 0;

        // The character at position $i
        $i = 0;
        // Character after the character at position $i
        $j = 0;
        $queryLength = strlen($sql);
        // SQL query whose named parameters are replaced with question marks (?)
        $rewrittenQuery = '';
        // The end position of the previous named parameter
        $endIndex       = 0;
        // New parameter is built up or not
        $processed      = false;

        while ($i < $queryLength)
        {
            $skipToPosition = self::_moveToEndOfSkippedBlock($sql, $i);
            $skipToPosition = self::_moveToEndOfSpecialSymbol($sql, $skipToPosition);

            if ($i < $skipToPosition)
            {
                // End of the SQL statement
                if ($skipToPosition >= $queryLength)
                {
                    break;
                }

                $i = $skipToPosition;
            }

            $c     = $sql[$i];

            // Signal to indicate that a named parameter may possibly be found
            if ($c == ':' || $c == '&')
            {
                // Find the end of the named parameter
                // Next character
                $j = $i + 1;

                // Move until a separator is meet
                while ($j < $queryLength && false === self::_isParameterSeparator($sql[$j]))
                {
                    $j++;
                }

                // Named parameter length can not be empty
                if ($j - $i > 1)
                {
                    $parameter = substr($sql, $i, $j - $i);

                    // Named parameters can not be duplicate
                    if (true === isset($namedParameters[$parameter]))
                    {
                        throw new SpicaDatabaseException('Named parameter can not be duplicate: '.$parameter);
                    }

                    $namedParameters[$parameter] = $totalParameterCount++; // positional parameters and named parameters can be mixed
                    $namedParameterCount++;
                    // Build up new query by removing the string part that contains named parameter
                    $rewrittenQuery .= substr($sql, $endIndex, $i - $endIndex).'?';
                    $endIndex        = $j;
                }
            }
            else
            {
                if ($c == '?' && $i < $queryLength - 1 && true === self::_isParameterSeparator($sql[$i + 1]))
                {
                    $positionalParameterCount++;
                    $totalParameterCount++;
                }
            }

            $i++;
        }

        // The build of $rewrittenQuery stops where the last named parameter found (may be in the middle of the query)
        // and it can leave $rewrittenQuery immature. Here I continue to build up $rewrittenQuery util the end of original query
        $rewrittenQuery                               .= substr($sql, $endIndex, $queryLength);
        $namedParameterQuery->parameterNames           = $namedParameters;
        $namedParameterQuery->namedParameterCount      = $namedParameterCount;
        $namedParameterQuery->positionalParameterCount = $positionalParameterCount;
        $namedParameterQuery->totalParameterCount      = $totalParameterCount;
        $namedParameterQuery->rewrittenQuery           = $rewrittenQuery;
        return $namedParameterQuery;
    }

    /**
     * Sets path to keep rewritten SQL queries.
     *
     * @param string $path
     */
    public static function setCachePath($path)
    {
        if (false === is_dir($path))
        {
            throw new Exception('User defined directory path for cached rewritten queries at "'.$path.'" does not exist.');
        }

        self::$_cachePath = $path;
    }

    /**
     * Allows to reuse cached rewritten queries or not.
     *
     * @param bool $mode
     */
    public static function setCacheMode($mode)
    {
        self::$_cacheMode = $mode;
    }

    /**
     * Gets cache mode.
     *
     * @return bool
     */
    public static function getCacheMode()
    {
        return self::$_cacheMode;
    }

    /**
     * Gets cached path.
     */
    public static function getCachePath()
    {
        if (null === self::$_cachePath)
        {
            self::$_cachePath = dirname(dirname(dirname(dirname(dirname(dirname(__FILE__)))))).'/cache/queries'; // ${base_path}/cache/queries

            if (false === is_dir(self::$_cachePath))
            {
                throw new Exception('Default directory path for cached rewritten queries at BASE/cache/queries does not exist.');
            }
        }

        return self::$_cachePath;
    }

    /**
     * Is the rewritten version of the original query cached?
     *
     * @param string $sql
     */
    public static function isCached($sql)
    {
        return file_exists(self::getQueryCacheFile($sql));
    }

    /**
     * Reads cached content and return an object of SpicaNamedParameterQuery
     *
     * @param  string $sql
     * @return SpicaNamedParameterQuery
     */
    protected static function getCachedObject($sql)
    {
        return serialize(file_get_contents(self::getQueryCacheFile($sql)));
    }

    /**
     * Saves the rewritten query into a cache file.
     *
     * @param string $sql Original query
     * @param string $rewrittenQuery
     */
    protected static function saveRewrittenQuery($sql, $rewrittenQuery)
    {
        file_put_contents(self::getQueryCacheFile($sql), $rewrittenQuery);
    }

    /**
     * Gets file path to the file which stores the rewritten query.
     *
     * @param string $sql
     */
    private static function getQueryCacheFile($sql)
    {
        return self::getCachePath().DIRECTORY_SEPARATOR.md5($sql);
    }

    /**
     * Skips over special characters and symbols
     *
     * @param  string $sql SQL statement
     * @param  int    $position current position of statement
     * @return int    the next position to process after any comments or quotes are skipped
     */
    private static function _moveToEndOfSpecialSymbol($sql, $position)
    {
        $charactersToSkip = self::$_skippedSymbols;

        // Check all skipped block symbols
        for ($i = 0, $totalSymbol = count($charactersToSkip); $i < $totalSymbol; $i++)
        {
            $symbolToCheck  = $charactersToSkip[$i];
            $lengthOfSymbol = strlen($symbolToCheck);

            // Check the whole length of the symbol
            if ($symbolToCheck !== substr($sql, $position, $lengthOfSymbol))
            {
                // Not match
                continue;
            }

            $position = $lengthOfSymbol + $position;
        }

        return $position;
    }

    /**
     * Skips over comments and quoted names present in an SQL statement
     *
     * @param  string $sql SQL statement
     * @param  int    $position current position of statement
     * @return int    The next position to process after any comments or quotes are skipped
     */
    private static function _moveToEndOfSkippedBlock($sql, $position)
    {
        $charactersToSkip = self::$_startSkippedBlock;

        // Check all skipped block symbols
        for ($i = 0, $totalSymbol = count($charactersToSkip); $i < $totalSymbol; $i++)
        {
            $symbolToCheck  = $charactersToSkip[$i];
            $lengthOfSymbol = strlen($symbolToCheck);

            // Check the whole length of the symbol
            if ($symbolToCheck !== substr($sql, $position, $lengthOfSymbol))
            {
                // Not match
                continue;
            }

            // The start block of skipped symbols is matched
            // Now we looking for string index that marks the end of skipped block symbol
            $endSymbol = self::$_endSkippedBlock[$i];

            // Assuming that the block is not closed
            $endIndex  = false;

            if (true   === is_array($endSymbol))
            {
                foreach ($endSymbol as $symbol)
                {
                    $endIndex  = strpos($sql, $symbol, $position + $lengthOfSymbol);

                    if (false !== $endIndex)
                    {
                        $endSymbol = $symbol;
                        break;
                    }
                }
            }
            else
            {
                $endIndex = strpos($sql, $endSymbol, $position + $lengthOfSymbol);
            }

            if (false    !== $endIndex)
            {
                $position = $endIndex + strlen($endSymbol) - 1;
            }
            else
            {
                // character sequence ending comment or quote not found
                $position = strlen($sql); // Move the end of the SQL
            }
        }

        return $position;
    }

    /**
     * Determines whether a parameter name ends at the current position,
     * that is, whether the given character qualifies as a separator.
     *
     * @param  string $character A character
     * @return bool
     */
    private static function _isParameterSeparator($character)
    {
        // Most frequently encountered separator
        if (' ' === $character)
        {
            return true;
        }

        return in_array($character, self::$_separators);
    }
}

/**
 * This class is internallly used by <code>SpicaNamedParameterParser</code>
 * to holds information about a processed SQL statement that contains named parameters
 *
 * @category   spica
 * @package    core
 * @subpackage datasource\db
 * @author     Pham Cong Dinh <pcdinh at phpvietnam dot net>
 * @since      Version 0.1
 * @since      October 26, 2008
 * @copyright  Pham Cong Dinh (http://www.phpvietnam.net)
 * @license    http://www.gnu.org/licenses/lgpl-3.0.txt
 */

/**
 * @see        SpicaNamedParameterParser
 * @internal   This class is used internally in Spica database packages
 */
class SpicaNamedParameterQuery
{
    /**
     * Original SQL query
     *
     * @var string
     */
    public $originalQuery;

    /**
     * Rewritten SQL query
     *
     * This query contains positional parameters only
     *
     * @var string
     */
    public $rewrittenQuery;

    /**
     * Named parameters and its equivalent position
     *
     * @var array
     */
    public $parameterNames = array();

    /**
     * Total of named parameters found
     *
     * @var int
     */
    public $namedParameterCount;

    /**
     * Total of positional parameters found
     *
     * @var int
     */
    public $positionalParameterCount;

    /**
     * Total of parameters (positional and named ones) found
     *
     * @var int
     */
    public $totalParameterCount;

    /**
     * Constructs a new instance of the {@link SpicaNamedParameterQuery} class
     *
     * @param string $originalQuery the SQL statement that is being (or is to be) parsed
     */
    public function  __construct($originalQuery)
    {
        $this->originalQuery = $originalQuery;
    }
}

?>